分布式事务实践 您所在的位置:网站首页 分布式事务 实现 分布式事务实践

分布式事务实践

2023-07-12 09:43| 来源: 网络整理| 查看: 265

记录一下最近工作中遇到的业务场景以及基于该场景的学习和理解,并且简单实现的一个小的Saga框架。

业务场景

简单抽象地描述下业务场景:有四个模块A、B、C、D,其中B、C、D是下游服务,A是上游服务调用方。从前端进来一笔请求进入A,A需要对这一笔请求进行记录落库,之后串行地调用B、C服务,并且异步地调用D服务,调用结束后在对数据库里这笔请求进行更新操作。

用我们最熟悉的电商的场景来说就是下列几个步骤:

用户在订单平台操作下单占库存占钱款生成一笔订单

如下图所示:

分布式系统

在聊更细节的业务场景之前,我们先来看看上面这张图。上图是一个分布式应用的的简单呈现,将电商的业务拆分成了多个服务。在分布式应用调用的场景中,往往会遇到「网络通信异常」、「服务器节点异常」、甚至「服务器宕机」等场景。有一个说法就是,在分布式应用中遇到的各种异常场景一定会比你想的到的还要多,发生地也更加频繁。

那既然分布式应用需要考虑这么多的异常场景,为什么我们还要使用分布式的方式部署服务呢?在过去一个网站,流量还没那么大的时候,单机是完全可以hold住的。但随着互联网业务的快速发展,你说在当今,春运的时候一个12306如果不能承载住上百上千万人的访问,网上吐槽声就已经一片。于是我们开始追求网站的高可用、高性能、容易扩展伸缩性强且安全的系统。这也是分布式系统存在的意义。

分布式系统有以下几点「特点」:

「高可用」:在出现一台或多台服务器节点异常甚至宕机的情况下,系统仍然可用,采用分布式冗余部署多节点来保证高可用。「高性能」:在单机的情况下一台机器的CPU、内存等因素容易成为性能的瓶颈,而在分布式多机部署的情况下,通过拆分业务多机部署达到高性能。「伸缩性强」:在访问量增大和存储需求提高的时候,一台或几台服务器就显得势单力薄,此时可以通过向服务器集群中添加服务器来应对。「扩展性强」:需要将扩展性和伸缩性区分开来看待,伸缩性是基于底层硬件来说的,而扩展性则是针对业务来说的,也就是说便于新业务的扩展或者是业务的拆分。并且一般采用模块化,所以更便于扩展也便于模块重用。业务被拆分,开发效率也会随之提升。

另外,分布式需要重点考虑在服务调用「成功、失败、超时异常」三种场景下,如何保证「数据一致性」的问题,这也是我们最关心的问题,谁也不希望自己买张春运票钱都花出去了结果票到人家手里了,这年头的分布式系统总得讲究一手交钱一手交货吧。

数据一致性

我们在很多文章里面经常会听到「一致性」这个名词,在使用数据库的时候提到的「ACID」里的C是一致性,分布式系统「CAP理论」里面的C也是一致性,那这两者究竟有什么区别呢?我们今天这里聊到的一致性又指的是什么呢?接下来我们好好聊一聊。

ACID

ACID指的是在数据库写入或更新时为了保证事务正确可靠而必须满足的四个特性,分别是「A(Atomicity 原子性)、C(Consistency 一致性)、I(Isolation 隔离性)、D(Durability 持久性)」。其他三个特性我们暂且搁下不谈,这里的一致性指的是事务上的一致性,我理解的其他三个特性最终都是为了这个一致性而服务。这里的一致性指的是在事务发生的前后,系统从一个正确的状态,转移到另一个正确的状态。

什么叫「正确的状态」呢?举个简单的例子,A、B账户各有100元,A向B转账50元,转账后A的账户变成了50元,B账户变成了150元,这是正确的状态。如果此时A又向B转账转了100元,之后A的账户变成了-50元,B的账户变成了250元,那么这就是错误的状态。在这个例子里,如果变成了错误的状态,事务是需要回滚的,这就是事务上的一致性。一般来说这个一致性可以是数据库做约束来保证,但更多时候业务逻辑更为复杂,需要代码逻辑层面去约束保证。

CAP

CAP理论通常用于分布式系统中,CAP分别指「C(Consistency 一致性)、A(Availability 可用性)、P(Partition tolerance 分区容忍性)」,CAP理论认为在分布式系统中这三个特性只能同时满足两个。而我们所说的数据一致性也就是指的这个一致性,这个一致性和ACID中的事务一致性完全不是同一个东西,千万不要搞混了。

「一致性」:在分布式系统中所有的数据副本,在同一个时刻有同样的值。「可用性」:在分布式系统的集群中某个或多个节点故障了,客户端的读写请求仍然会有响应。「分区容忍性」:当集群中的节点与节点之间无法通信时,或者系统在一定时限内无法达到数据一致性,就意味着产生了分区。而分区产生后仍然可用,即分区容忍性。

那为什么只能同时满足两个呢,首先考虑要满足分区容忍性的情况下,就要做多个副本,但是一旦副本多起来,一致性就会受到影响,数据同步需要花更多时间,数据同步花的时间多了,就会对可用性造成影响,有可能有段时间无法使用,同时分区容忍性由于达到一致性的时间变长了,则无法很好地满足。归纳起来就是:

「满足C\A不满足P」,即不要求分区,则节点受限,也就是说放弃了系统的伸缩性,这跟分布式的特性是相违背的,通常采用这种方案的是传统的数据库如Oracle、Mysql。「满足C\P不满足A」,则不要求可用性,要求一致性即所有副本的数据要保持一致,而分区则有可能导致数据同步的时间大大地延长,这种场景下就牺牲了用户的体验,等数据全部一致了用户才可以访问。通常分布式数据库会采用这种方案。「满足A\P不满足C」,则不要求一致性,一旦分区发生,又要保持可用,每个节点就会用本地存储的数据来提供服务,这样用户就有可能访问到过期的数据。这种场景下会稍微影响用户体验,但是保证了高可用不至于流程阻塞。

前后的矛盾冲突导致无法同时满足三者,因此我们一般在实现中会采用一些折中的方案,这时就要提到在一致性上根据实际需求而产生的几种不同的一致性:

「强一致性」:系统中某个数据被更新了,那么后续对于该数据的读取都将得到更新后的值,也就是说在所有副本上数据要保持严格的一致,副本是同步更新的。「弱一致性」:相对强一致性而言,数据更新后,对该数据的读取并不能保证总是读取到更新后的值,也就是副本的数据无法保证一致,副本是异步更新的。「最终一致性」:属于一种特殊的弱一致性,系统中所有的副本在经过一个短暂的数据不一致后通过同步最终达到一致。

在我们这次的业务场景中,上游服务A对于服务B、C、D的调用要保证数据最终一致性。对于服务B、C是同步调用,在服务调用过程中如果失败则事务直接进行回滚。对于服务D异步调用可以容忍较长时间的数据不一致,在最终一致性之前引入「软状态(中间状态)「作为临时状态,当异步调用结果还没有返回时,可以设置一个」订单生成中」的状态。

容错机制

我们这种业务,总是和钱在打交道,所以我们考虑的重点首先肯定是不能错(数据一致性),错了之后怎么解决或者系统如何实现自动处理是我们接下来考虑的(容错机制)。

容错的话需要考虑多种场景,流程顺利成功的话自不必说,失败的话就要考虑实现自动回滚系统,而像因为网络或服务器节点原因而导致的超时则是需要特殊考虑的异常场景。

成功场景

成功场景表示同步调用成功以及异步发起请求这个动作成功,这个时候会同步返回给用户表示请求成功。但是有一点需要注意的是异步请求后不代表订单就生成成功了,此时返回给用户的只是给用户一个响应而不至于流程阻塞。所以我们此时会引入软状态,也就是中间状态类似于「订单生成中」的状态来表示。订单生成有可能成功也有可能失败,如果订单生成失败则同样需要做回滚的处理。

失败场景

这里展示了三种失败的场景,对于前两种,失败了就需要做回滚的处理:

当调用服务B冻结库存失败则终止并回滚数据库。当调用服务C冻结钱款失败则终止并回滚服务B解冻库存以及回滚数据库。

对于第三种失败场景则多了几种选择:

可以同前两种一样直接做「回滚」的处理可以选择提供给用户「手动重试」的机会,因为一般成功冻结库存和冻结钱款后,这笔订单已经大概率可以下达成功了,异步发起请求失败是极小概率的事情,通过提供重试机制来提升用户的体验。可以将异步请求写入数据库或者消息队列,另起一个线程去处理异步请求以及后续的操作,包括可以设定「自动重试」以及收到异步返回结果后成功和失败的处理。这样的好处在于可以很好地将代码解耦开,业务流程较为清晰。 超时/异常场景

超时的原因多种多样,「网络抖动、服务器节点不通、服务器节点崩溃」,出现超时的时候总是程序员们抓狂找原因的时候。而且超时还有一个难点在于特别容易导致状态的不一致, 当服务A调用服务B超时了,此时无法得知B是否有接收到请求,在这种情况下有两种解决方案,一种是要求B要实现「幂等」或者提供「状态查询接口」,服务A通过重试来处理超时情景,或者采用「强制回滚」来处理超时,但是当这两种方法都再次超时的时候就不得不人工介入处理,此时则还需要预留好后续的提供给运维人员的人工处理手段。

宕机场景

突然的停电、插头被人拔了又或是有人恶意杀进程,应用可经不起这样的折腾,一旦出现那么保存在内存中的状态和数据就全部没了。假若说此时服务A已经成功调用服务B冻结库存成功了,还没有调用服务C就突然断电了,那么势必会造成各个服务上的数据不一致,库存都已经扣了,钱却没有扣订单也没有生成,这不是莫名就多扣了库存嘛?所以在调用服务前一定要将现场的数据保存下来,方便宕机重启后进行回滚操作。

在宕机重启系统后,会启动一个线程去读取保存的现场数据,基于现场数据去做回滚,因此现场数据应该包含有几个信息:「被调用模块、传递参数、状态」(判断该数据是否已经用于回滚处理,如果已经进行回滚则为已处理,否则为待处理)。

方案一:TCC

在一开始我们打算使用TCC框架来实现微服务之间的调用以解决上述的业务场景。那么什么是TCC框架呢?

TCC其实是三个单词的首字母,分别是:try、confirm、cancel,可以说凭字面意思就可以猜出个大概了。try就是尝试一下,confirm就是确认,cancel就是取消。就好像你打算晚上约上几个朋友去吃小龙虾,然后就约了几个朋友预定了座位,这就是try。朋友约到了座位预订到了准时五点半下班去恰饭,这就是confirm。因为你要修复bug不得不加班、或朋友都在加班、或没预订到座位,那就没得小龙虾吃了,这就是cancel。

话不多说,我们结合实际的业务场景用大白话+图让大家理解一下。

正常逻辑 阶段一:try

在try阶段,「调用方」会分别调用多个「被调用方」,只有当多个被调用方都返回成功的时候,才会进行confirm,否则就会进行cancel。也就是说try阶段是一个预备类的操作,锁定某个资源、冻结部分数据,是在真正的扣除之前进行一个预占、冻结的动作。

阶段二:confirm

在该阶段,confirm就是对try成功了之后的一个后续处理,将冻结、预占的数据进行真正的扣除(该业务场景是扣除,当然在其他场景也可能是增加),这里就要考虑到两种场景。

「第一种」是confirm都成功了,那么对服务B、C的调用就此完成,在数据上服务也都是一致的。

「第二种」是个别或者全部的confirm失败了或者调用超时了,和try阶段不一样的是,这种场景下不做cancel回滚处理,而是将所有的confirm进行重试。需要注意的是,被调用方一定要实现「幂等机制」,否则是无法进行重试的。另外,需要设定重试次数,如果重试还是失败或超时并且已经超过了重试次数的,则必须告警通知人工进行处理。

阶段三:cancel

只有在try阶段失败或异常超时的情况下才会进行cancel处理,并且cancel处理并不是对所有的被调用方进行cancel处理,而是对已经try成功的被调用方进行cancel处理。另外,和confirm阶段一样,cancel阶段也需要提供重试机制和重试超过一定次数的情况下告警通知人工处理。

进一步思考

聊到这,基本上就能知道TCC框架大概是个怎么回事了。在具体实现中,可以选用开源的TCC框架:ByteTCC、Himly、TCC-transaction、Seata等等。或者是自己手写,不过手写这么个框架内部的实现细节还是很复杂的,各个阶段的执行情况怎么感知,如何推动到下一步阶段,如何实现重试机制、告警系统、人工介入、宕机回滚等等等等都要考虑清楚。

接口改造

另外如果要采用TCC框架,那么所有服务都要统一采用TCC框架。对于被调用方来说,原本的一个接口需要改造成三个接口:try-confirm-cancel。

「try阶段」需要实现对数据的冻结、预占,同时需要保存该次调用记录进行数据留痕以及支持幂等机制。「confirm阶段」需要对冻结、预占的数据进行扣除/增加,并且实现幂等机制,在多次调用的情况下返回相同的结果(通常来说confirm结果都是成功,一般是调用超时了需要重试,如果明确返回失败结果的话则大概率要人工介入或者说视情况而定允许回滚)。「cancel阶段」需要取消冻结、预占的数据,也就是一个回滚操作,被调用方回滚到被调用前,数据保证一致性。同样的,cancel需要实现幂等机制,超时的情况下允许重试,而明确返回失败结果的情况下则大概率要人工介入处理。 幂等机制

上述频繁提到「幂等机制」,为什么幂等机制这么重要,就是因为系统很有可能因为超时而需要重发。为了不因为重复的调用而占用系统资源,以及保证confirm和cancel的正常执行,被调用方保证幂等机制至关重要。

幂等表示一次请求和重复的多次请求对系统资源的影响是一致的,返回的结果也是一致的。因此幂等在实现上通常要引入「事务控制表」记录请求数据,每一次请求都会有一个事务ID,重发的请求则事务ID一致,通过对事务ID的比对来实现幂等。

其他异常处理

另外,我们在上述阶段里都只考虑了被调用方返回结果的各种场景,但是如果说「调用方系统出现异常」了,那么同样需要分情况进行处理:

「在try阶段」发生错误/异常/超时了需要进行cancel回滚,在这里需要考虑两种特殊情况: 「被调用方收到了cancel,没有收到try」:这个时候被调用方由于没有进行try,所以不需要做任何处理,也就是「空回滚」;「被调用方收到了cancel,之后又收到了try」:这个时候被调用方需要对前面的cancel进行空回滚,并对后面的try进行识别,识别出已经进行过cancel处理,故不再处理该次try调用,这也就是所谓的「防悬挂控制」;空回滚和防悬挂控制通常也可以采用引入事务控制表来进行实现; 「在confirm、cancel阶段」的异常则需要重试或者需要人工介入处理;如果「confirm阶段已经结束了」出现异常,则视情况考虑是否进行回滚,比如是异步调用出现异常那可以考虑重试机制,如果是数据库操作异常,那大概率需要进行回滚。

还有一点,我们在宕机场景中已经考虑过,如果宕机了一定要保存现场数据,保存该TCC事务框架的各个阶段状态。

PS:思考一下我们在生产环境中如何通过TCC框架保证系统的高可用呢?而不是频繁的人工介入处理?笔者在实践中主要采用的方法是基于高可用的MQ中间件实现,如kafka或者rabbitMq,感兴趣的朋友也可以自行了解。

方案二:Saga

Saga框架在笔者看来,就是一个没有了confirm的TCC框架,但这么说也不太准确,因为Saga的try不再是尝试一下,没有了预占和冻结数据,而是直接对数据进行了扣除/增加。

Saga理论来自Hector & Kenneth 1987发表的论文 Sagas,其中最主要的思想就是补偿协议:

「补偿协议」:在Saga模式下,分布式事务内有多个参与者,每个参与者都是正向补偿服务。上图中的T1~Tn就是「正向调用」,C1~Cn是「补偿调用」,正向调用和补偿调用是一一对应的关系。假设有n个被调用方服务,T1就是对服务一的调用,接着T2是对服务方二的调用,T3是对服务方三的调用。如果这个时候返回了失败,那么就需要进行回滚,此时就会调用T2的对应补偿C2,调用T1的对应补偿C1,使得分布式事务回到初始状态。

Saga适用场景 Saga是一种「长事务的解决方案」,更适合于「业务流程长、业务流程多」的场景;如果服务参与者包含其他公司或遗留系统服务,此时无法提供TCC模式下的三个接口,那么就需要采用Saga;典型的业务系统:金融机构对接系统(需要对接外部系统)、渠道整合(流程长)、分布式架构服务;银行金融机构使用更为广泛; Saga和TCC的异同

从上面的表述来看,你一定会觉得Saga不是跟TCC很像吗?既生TCC何生Saga呢?其实他们还是有不同之处,主要体现在应用场景上的不同。

数据隔离性

回到我们的业务场景,服务A调用了服务B、C,如果发生了以下的情况:

1)对服务B try成功了,但是服务C try失败了;2)此时有用户来读取服务B和服务C的数据;3)A、B、C进行回滚;

那么对于TCC还是Saga来说,try失败了都会进入cancel阶段,但是好巧不巧在还没有来得及进行cancel回滚处理的时候,有用户来读取B、C的数据。在这种情况下TCC和Saga就会返回不一样的结果。

对于TCC来说,try只是一个冻结的操作,所以他操作的也只是一个**"冻结字段"**,该字段并不会影响用户查询的实际数据,所以是可以返回正确的结果的。

对于Saga来说,try则是对数据的一个直接操作,会更改用户想要访问的数据,那么这个时候就会返回给用户脏数据。会有脏读那么就说明Saga框架下数据是有「隔离性」的问题的。

对于Saga来说,隔离问题本质上是要「控制并发」,因此还是要回到业务上来,从业务逻辑层去实现并发控制。可以是在应用层加锁的机制去处理,也可以是Session层面保证串行化等,但是这些都会多多少少对性能和吞吐量有所影响。

另外,由于 Saga 不保证隔离性,我们在业务设计的时候需要做到**"宁可长款,不可短款"**的原则,也就是说宁可账户上的钱多了,也不可少钱,钱多了还能找回,钱少了可就没踪影了。所以在做业务设计的时候,一定是先入账再扣款,保证即使数据隔离性作祟钱也一分都不会少。

两阶段与一阶段

从上面的叙述中,Saga相对于TCC主要在隔离性上的缺失,究其原因,是因为TCC是两阶段提交事务而Saga是一阶段提交事务。

对于TCC,「一阶段」会将资源进行预占,对资源进行锁定,「二阶段」才会使用资源或释放资源。

对于Saga,则是「一阶段」直接进行事务提交。相比TCC的二阶段提交事务,一阶段提交事务「无锁」,且可以采用「事件驱动异步执行」,适合「长流程」的业务,另外异步执行也意味着更高的「吞吐量」。

但是一阶段提交导致Saga有隔离性问题,那么在cancel(补偿)失败的时候,TCC可以采用重试机制去处理,而Saga需要提供额外的人工介入处理。

总结

「TCC框架」

在业务上侵入性更大,需要实现try、confirm、cancel三个接口;没有数据隔离性问题;两阶段事务提交,虽然有锁,但是可以通过业务锁的方式来提高并发能力;

「Saga框架」

适用于无法改造接口的场景;有数据隔离性问题,可能产生脏数据、更新丢失、模糊读取等问题;一阶段事务提交,无锁,适用于长流程业务逻辑;适用于事件驱动异步执行,吞吐量更高;补偿失败的场景下需要额外的异常处理,如人工介入;

两者之间同样有一些相同点,在幂等机制、空回滚、防悬挂设计、业务锁、异常处理机制上,二者可以相互借鉴实践。

简易Saga框架实现

最终我们在选型上选定了Saga,主要还是因为其无需改造接口、实现简单的特点,但是又由于我们业务上流程节点并不多,所以并没有采用事件驱动异步执行的设计,而是采用了「注解+拦截器拦截业务的正向服务实现方式」。

基本实现方法是对业务流程代码添加@SagaTransactional的注解,在注解内部实现对流程代码中的异常捕获进行回滚处理。

定义通用的消息结构体SagaAction,结构体内部包含需要落库Saga事务控制表的信息(「事务ID、业务类型、业务ID、下游系统ID、发送下游系统信息正向报文、发送下游系统信息补偿报文、事务状态」),以及可以「定制化的操作标识」,如是否手动回滚(适用于不抛出异常又需要回滚的场景)、超时处理机制(超时后可定制是否重发或回滚)、异常处理机制(异常后可定制是否重发或回滚)等。

会对每一次请求都进行「数据库持久化记录」,保证系统宕机后可以通过读取数据库信息正常进行回滚或重试操作。

提供「兜底机制」,在业务流程开始时注册兜底机制,在异常抛出后,Saga框架会自动进行回滚处理,回滚结束后兜底程序执行,主要用于进行修改中间状态或保证数据一致等操作。

提供「Saga事务性处理异常机制」,当补偿失败或异常超时时,可以定制化后续操作,包括「告警人工介入、自动重发处理」等。

综述

综上,简单记录了工作过程中TCC和Saga框架的学习和实践,先介绍了分布式系统的相关理论,然后重点讲解了业务场景以及TCC和Saga的分布式事务实现,在实际的过程中大家可以根据需求和业务情况自主进行选择。由于保密问题无法提供源码,后续会编写一个demo供参考,如有发现错误,欢迎指正纠错!

参考:

https://www.sofastack.tech/blog/sofa-meetup-3-seata-retrospect/



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有